Terraform v0.7.8でAWS WAFに対応しました
はじめに
こんにちは、中山です。
2016年11月1日にTerraformのv0.7.8がリリースされました。さまざまなアップデートがありますが、AWS WAFへの対応が個人的には大きなポイントでした。早速使ってみたのでレポートします。
構成図
今回作成する構成は以下のとおりです。
WAFが紐付いたCloudFrontのオリジンとしてELBを作成し、後段にEC2を起動しておきます。
コード
GitHubに上げておきました。ご自由にお使いください。
WAF関連のリソース
AWS WAFを作成するためのコードとその意味を以下に記載します。なおそれぞれの情報は現時点(2016年11月4日)の情報であることに留意してください。お使いになる場合は各種ドキュメントを参照してください。
リソース名 | 用途 |
---|---|
aws_waf_ipset | IPマッチコンディションの作成 |
aws_waf_byte_match_set | 文字列マッチコンディションの作成 |
aws_waf_size_constraint_set | サイズ制約コンディションの作成 |
aws_waf_sql_injection_match_set | SQLインジェクションマッチコンディションの作成 |
aws_waf_xss_match_set | クロスサイト・スクリプティングマッチコンディションの作成 |
aws_waf_rule | ルールの作成 |
aws_waf_web_acl | Web ACLの作成 |
aws_waf_ipset
コードは以下のとおりです。
resource "aws_waf_ipset" "ipset" { name = "${var.name}IpSet" ip_set_descriptors { type = "IPV4" value = "${var.ipset_config["value"]}" } ip_set_descriptors { type = "IPV6" value = "2620:0:2d0:200::/64" } }
設定内容は以下のとおりです。なお、詳細な設定はドキュメントを参照してください。
設定 | 値 | 必須の有無 |
---|---|---|
name |
IPマッチコンディションの名前。 | Yes |
ip_set_descriptors |
コンディションの条件。 | No |
ip_set_descriptors
で指定可能な値は以下のとおりです。
設定 | 値 | 必須の有無 |
---|---|---|
type |
コンディションのタイプ。 IPV4 と IPV6 をサポート。 |
Yes |
value |
コンディションの値。IPv4アドレスかIPv6アドレスをサポート。 | Yes |
aws_waf_byte_match_set
コードは以下のとおりです。
resource "aws_waf_byte_match_set" "byte_set" { name = "${var.name}ByteSet" byte_match_tuples { text_transformation = "LOWERCASE" target_string = "test-string" positional_constraint = "CONTAINS" field_to_match { type = "HEADER" data = "user-agent" } } }
設定内容は以下のとおりです。なお、詳細な設定はドキュメントを参照してください。
設定 | 値 | 必須の有無 |
---|---|---|
name |
文字列マッチコンディションの名前。 | Yes |
byte_match_tuples |
コンディションの詳細。 | No |
byte_match_tuples
で指定可能な値は以下のとおりです。
設定 | 値 | 必須の有無 |
---|---|---|
field_to_match |
HTTPリクエストのどの部分にマッチさせるかを指定。 data と type を指定可能。 |
Yes |
positional_constraint |
指定した条件をどのようにマッチさせるか。 | Yes |
target_string |
マッチさせる文字列。 | No |
text_transformation |
条件に指定した文字列をWeb ACLで評価させる前にどのように変換させるか。 | Yes |
aws_waf_size_constraint_set
コードは以下のとおりです。
resource "aws_waf_size_constraint_set" "size_constraint_set" { name = "${var.name}SizeConstraintSet" size_constraints { text_transformation = "NONE" comparison_operator = "GT" size = "15" field_to_match { type = "HEADER" data = "user-agent" } } }
設定内容は以下のとおりです。なお、詳細な設定はドキュメントを参照してください。
設定 | 値 | 必須の有無 |
---|---|---|
name |
サイズ制約コンディションの名前。 | Yes |
size_constraints |
コンディションの条件。 | Yes |
size_constraints
で指定可能な値は以下のとおりです。
設定 | 値 | 必須の有無 |
---|---|---|
field_to_match |
aws_waf_byte_match_set と同じ。 |
Yes |
comparison_operator |
サイズの比較演算子。 | Yes |
size |
条件とするサイズ。 | Yes |
text_transformation |
aws_waf_byte_match_set と同じ。 |
Yes |
aws_waf_sql_injection_match_set
コードは以下のとおりです。
resource "aws_waf_sql_injection_match_set" "sql_injection_match_set" { name = "${var.name}SqlInjectionMatchSet" sql_injection_match_tuples { text_transformation = "URL_DECODE" field_to_match { type = "QUERY_STRING" } } }
設定内容は以下のとおりです。なお、詳細な設定はドキュメントを参照してください。
設定 | 値 | 必須の有無 |
---|---|---|
name |
コンディション名。 | Yes |
sql_injection_match_tuples |
コンディションの詳細。 | No |
sql_injection_match_tuples
で指定可能な値は以下のとおりです。
設定 | 値 | 必須の有無 |
---|---|---|
field_to_match |
aws_waf_byte_match_set と同じ。 |
Yes |
text_transformation |
aws_waf_byte_match_set と同じ。 |
Yes |
aws_waf_xss_match_set
コードは以下のとおりです。
resource "aws_waf_xss_match_set" "xss_match_set" { name = "${var.name}XssMatchSet" xss_match_tuples { text_transformation = "NONE" field_to_match { type = "QUERY_STRING" } } }
設定内容は以下のとおりです。なお、詳細な設定はドキュメントを参照してください。
設定 | 値 | 必須の有無 |
---|---|---|
name |
コンディション名。 | Yes |
xss_match_tuples |
コンディションの詳細。 | Yes |
xss_match_tuples
で指定可能な値は以下のとおりです。
設定 | 値 | 必須の有無 |
---|---|---|
field_to_match |
aws_waf_byte_match_set と同じ。 |
Yes |
text_transformation |
aws_waf_byte_match_set と同じ。 |
Yes |
aws_waf_rule
コードは以下のとおりです。
resource "aws_waf_rule" "ip_match_rule" { name = "${var.name}IPMatchRule" metric_name = "${var.name}IPMatchRule" predicates { data_id = "${aws_waf_ipset.ipset.id}" negated = false type = "IPMatch" } }
設定内容は以下のとおりです。なお、詳細な設定はドキュメントを参照してください。
設定 | 値 | 必須の有無 |
---|---|---|
name |
ルールの名前。 | Yes |
metric_name |
ルールに関するCloudWatchメトリクスの名前。 | Yes |
predicates |
ルールに関連付けるコンディションの設定。 | No |
predicates
で指定可能な値は以下のとおりです。
設定 | 値 | 必須の有無 |
---|---|---|
data_id |
コンディションのID。 | No |
negated |
ルールの真偽値を反転させるかどうか。 ture か false を指定。 |
Yes |
type |
コンディションのタイプ。 IPMatch / ByteMatch / SizeConstraint / SqlInjectionMatch / XssMatch を指定可能。 |
Yes |
aws_waf_web_acl
コードは以下のとおりです。
resource "aws_waf_web_acl" "ip_match_acl" { name = "${var.name}IPMatchAcl" metric_name = "${var.name}IPMatchAcl" default_action { type = "ALLOW" } rules { action { type = "BLOCK" } priority = 1 rule_id = "${aws_waf_rule.ip_match_rule.id}" } }
設定内容は以下のとおりです。なお、詳細な設定はドキュメントを参照してください。
設定 | 値 | 必須の有無 |
---|---|---|
default_action |
条件にマッチしなかった場合のデフォルトアクション。 | Yes |
metric_name |
Web ACLに関するCloudWatchメトリクスの名前。 | Yes |
name |
Web ACLの名前。 | Yes |
rules |
Web ACLにひも付けるルールの設定。 | No |
default_action
に指定可能な値は以下のとおりです。
設定 | 値 | 必須の有無 |
---|---|---|
type |
デフォルトアクションの動作。ALLOW / BLOCK / COUNT を指定。 |
Yes |
rules
に指定可能な値は以下のとおりです。
設定 | 値 | 必須の有無 |
---|---|---|
action |
ルールにマッチした場合の動作。設定可能な値は default_action と同じ。 |
Yes |
priority |
ルールの優先度。値が小さい方が優先される。 | Yes |
rule_id |
ひも付けるルールのID。 | Yes |
使ってみる
env/dev/secrets.tfvars
で拒否するIPv4アドレスを指定可能にしています。適宜作成してください。以下のコマンドでTerraformを実行可能です。デフォルトではWAFとCloudFrontを紐付けていません。
$ make ENV=dev ARGS='plan -var-file=secrets.tfvars' <snip> $ make ENV=dev ARGS='apply -var-file=secrets.tfvars' <snip> app_public_ip = 54.199.179.179 cf_domain_name = d2y3jm38cbb64z.cloudfront.net cf_id = E1EWV1B1CK4ACZ elb_dns_name = tfWafDemo-elb-198348460.ap-northeast-1.elb.amazonaws.com <snip>
今回テスト用に作成したCloudFrontのドメインは d2y3jm38cbb64z.cloudfront.net
となっています。まずはこのドメインに対してcurlでアクセスしてみます。
$ curl http://d2y3jm38cbb64z.cloudfront.net ip-172-16-100-82
バックエンドから正常にレスポンスが返されました。それでは本題のTerraformで作成したAWS WAFの挙動を確認したいと思います。今回は各種コンディション毎にWeb ACLを以下のように作成しました。
コンディション | ひも付けたWeb ACL |
---|---|
IPマッチコンディション | aws_waf_web_acl.ip_match_acl |
文字列マッチコンディション | aws_waf_web_acl.byte_match_acl |
サイズ制約コンディション | aws_waf_web_acl.size_constraint_acl |
SQLインジェクションマッチコンディション | aws_waf_web_acl.sql_injection_match_acl |
クロスサイト・スクリプティングマッチコンディション | aws_waf_web_acl.xss_match_acl |
CloudFrontは現状1つのディストリビューション毎に1つのWeb ACLしかひも付けることができません。そのため、テストしてみたいWeb ACLを変更する場合は以下のように cloudfront.tf
を修正して plan
/ apply
してください。
--- cloudfront.tf 2016-11-03 11:59:24.000000000 +0900 +++ cloudfront.tf.orig 2016-11-03 12:06:53.000000000 +0900 @@ -3,7 +3,7 @@ price_class = "PriceClass_200" retain_on_delete = true enabled = true - web_acl_id = "${aws_waf_web_acl.ip_match_acl.id}" + web_acl_id = "${aws_waf_web_acl.byte_match_acl.id}" origin { domain_name = "${aws_elb.elb.dns_name}"
CloudFrontに紐付いているWeb ACLは以下のコマンドで確認できます。
$ aws cloudfront get-distribution \ --id E1EWV1B1CK4ACZ \ --query 'Distribution.DistributionConfig.WebACLId' "25abd9b6-ed11-4a04-b83a-2e881b6aff41"
なお、エッジサーバに変更内容が反映されるまでしばらく時間がかかります。現在のステータスは以下のコマンドで確認できます。
$ aws cloudfront get-distribution \ --id E1EWV1B1CK4ACZ \ --query 'Distribution.Status' "Deployed"
IPマッチコンディションの動作
まずは、 aws_waf_ipset
リソースで作成したIPマッチコンディションの動作を確認してみます。今回は特定のIPアドレスからのアクセスを拒否しているので、正常に拒否されるかどうかを確認します。
$ curl http://d2y3jm38cbb64z.cloudfront.net <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd"> <HTML><HEAD><META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=iso-8859-1"> <TITLE>ERROR: The request could not be satisfied</TITLE> </HEAD><BODY> <H1>ERROR</H1> <H2>The request could not be satisfied.</H2> <HR noshade size="1px"> Request blocked. <BR clear="all"> <HR noshade size="1px"> <PRE> Generated by cloudfront (CloudFront) Request ID: eKBdNdIbZZVVaNmDZyJZ_W73lpGTPoGpAjbpJi_CrNHL262G8OIfdg== </PRE> <ADDRESS> </ADDRESS> </BODY></HTML>%
正常に拒否されました。コンディションの内容を確認すると指定したIPアドレスが設定されていることを確認できます。
$ aws waf get-ip-set \ --ip-set-id "$(aws waf list-ip-sets \ --query 'IPSets[].IPSetId' --output text)" { "IPSet": { "IPSetId": "ea26951d-b879-4cde-b702-501dfd436804", "Name": "tfWafDemoIpset", "IPSetDescriptors": [ { "Type": "IPV6", "Value": "2620:0000:02d0:0200:0000:0000:0000:0000/64" }, { "Type": "IPV4", "Value": "***.***.**.**/**" } ] } }
文字列マッチコンディションの動作
今回はユーザエージェントに含まれている文字列を小文字に変換した結果、 test-string
という文字列にマッチした場合ブロックする設定にしました。 curl
でユーザエージェントを指定してアクセスしてみます。
$ curl -H 'User-Agent: TEST2-STRING' http://d2y3jm38cbb64z.cloudfront.net ip-172-16-100-82 $ curl -H 'User-Agent: TEST-STRING' http://d2y3jm38cbb64z.cloudfront.net <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd"> <HTML><HEAD><META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=iso-8859-1"> <TITLE>ERROR: The request could not be satisfied</TITLE> </HEAD><BODY> <H1>ERROR</H1> <H2>The request could not be satisfied.</H2> <HR noshade size="1px"> Request blocked. <BR clear="all"> <HR noshade size="1px"> <PRE> Generated by cloudfront (CloudFront) Request ID: E6uafGx7LLbhecMWYFVS1y139FpQ_PlQM-YSVo5uqQDwmR8dZjW2gQ== </PRE> <ADDRESS> </ADDRESS> </BODY></HTML>%
正常に動作しているようです。文字列マッチコンディションの内容を確認すると意図した設定になっていることを確認できます。
$ aws waf get-byte-match-set \ --byte-match-set-id "$(aws waf list-byte-match-sets \ --query 'ByteMatchSets[].ByteMatchSetId' --output text)" { "ByteMatchSet": { "ByteMatchSetId": "5df9a91e-f3db-4f16-b53e-4d972fb4c803", "Name": "tfWafDemoByteSet", "ByteMatchTuples": [ { "TargetString": "dGVzdC1zdHJpbmc=", "PositionalConstraint": "CONTAINS", "TextTransformation": "LOWERCASE", "FieldToMatch": { "Data": "user-agent", "Type": "HEADER" } } ] } }
サイズ制約コンディションの動作
ユーザエージェントのサイズが15byte以上の場合にブロックする設定にしました。ユーザエージェントに適当な文字列を指定して curl
でアクセスしてみます。
$ curl 'http://d2y3jm38cbb64z.cloudfront.net' ip-172-16-100-82 $ curl -H 'User-Agent: abcdefghijklmnopqrstuvwxyz' http://d2y3jm38cbb64z.cloudfront.net <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd"> <HTML><HEAD><META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=iso-8859-1"> <TITLE>ERROR: The request could not be satisfied</TITLE> </HEAD><BODY> <H1>ERROR</H1> <H2>The request could not be satisfied.</H2> <HR noshade size="1px"> Request blocked. <BR clear="all"> <HR noshade size="1px"> <PRE> Generated by cloudfront (CloudFront) Request ID: p-5sgxx1bbr86cMutRzY07UdYDG7GMk22SNRJx_I4s21HkooqSu-aQ== </PRE> <ADDRESS> </ADDRESS> </BODY></HTML>%
正常に動作しているようです。サイズ制約コンディションの内容を確認すると意図した設定になっていることを確認できます。
$ aws waf get-size-constraint-set \ --size-constraint-set-id "$(aws waf list-size-constraint-sets \ --query 'SizeConstraintSets[].SizeConstraintSetId' --output text)" { "SizeConstraintSet": { "SizeConstraints": [ { "ComparisonOperator": "GT", "TextTransformation": "NONE", "FieldToMatch": { "Data": "user-agent", "Type": "HEADER" }, "Size": 15 } ], "SizeConstraintSetId": "baaf834a-cf9c-48f9-9a2d-e13e20fccaf3", "Name": "tfWafDemoSizeConstraintSet" } }
SQLインジェクションマッチコンディションの動作
クエリに渡された文字列をURLデコードし、不正なSQLが検出された場合にブロックする設定にしています。今回は簡単に curl
でテストしてみます。より詳細な動作確認を実施する場合はsqlmapなどを利用してください。
$ curl 'http://d2y3jm38cbb64z.cloudfront.net/index.html?=id1' ip-172-16-100-82 $ curl 'http://d2y3jm38cbb64z.cloudfront.net/index.html?id=1%20UNION%20ALL%20SELECT%20NULL--%20' <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd"> <HTML><HEAD><META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=iso-8859-1"> <TITLE>ERROR: The request could not be satisfied</TITLE> </HEAD><BODY> <H1>ERROR</H1> <H2>The request could not be satisfied.</H2> <HR noshade size="1px"> Request blocked. <BR clear="all"> <HR noshade size="1px"> <PRE> Generated by cloudfront (CloudFront) Request ID: 8kd8EtTftVaOIVnw_U0Z-K94IsBly2nUHHTDHFFfUkY7pZIDYxETKQ== </PRE> <ADDRESS> </ADDRESS> </BODY></HTML>%
ブロックされました。SQLインジェクションマッチコンディションの内容を確認してみます。
$ aws waf get-sql-injection-match-set \ --sql-injection-match-set-id "$(aws waf list-sql-injection-match-sets \ --query 'SqlInjectionMatchSets[].SqlInjectionMatchSetId' --output text)" { "SqlInjectionMatchSet": { "SqlInjectionMatchTuples": [ { "TextTransformation": "URL_DECODE", "FieldToMatch": { "Type": "QUERY_STRING" } } ], "Name": "tfWafDemoSqlInjectionMatchSet", "SqlInjectionMatchSetId": "75903417-bc48-4028-9e79-e4cc14d3de92" } }
クロスサイト・スクリプティングマッチコンディションの動作
クエリに渡された文字列が不正だと判断されたらブロックする設定にしています。こちらのエントリに記載されているクエリ文字列を参考にしてアクセスしてみます。
$ curl 'http://d2y3jm38cbb64z.cloudfront.net/' ip-172-16-100-82 $ curl 'http://d2y3jm38cbb64z.cloudfront.net/?<SCRIPT>alert(“Cookie”+document.cookie)</SCRIPT>' <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd"> <HTML><HEAD><META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=iso-8859-1"> <TITLE>ERROR: The request could not be satisfied</TITLE> </HEAD><BODY> <H1>ERROR</H1> <H2>The request could not be satisfied.</H2> <HR noshade size="1px"> Request blocked. <BR clear="all"> <HR noshade size="1px"> <PRE> Generated by cloudfront (CloudFront) Request ID: ECFcBiTzsO4wa1iAn1Raq0oCk55cbxFf6DxJoAfmRzKVyN1_jmi-Cg== </PRE> <ADDRESS> </ADDRESS> </BODY></HTML>%
ブロックされたようです。設定内容を確認してみます。
$ aws waf get-xss-match-set \ --xss-match-set-id "$(aws waf list-xss-match-sets \ --query 'XssMatchSets[].XssMatchSetId' --output text)" { "XssMatchSet": { "XssMatchTuples": [ { "TextTransformation": "NONE", "FieldToMatch": { "Type": "QUERY_STRING" } } ], "XssMatchSetId": "3db6a2da-d144-402e-9af9-7f486c9381be", "Name": "tfWafDemoXssMatchSet" } }
まとめ
いかがだったでしょうか。
AWS WAF自体がシンプルなため、それを利用する各種リソースも比較的簡単な設定になっています。ただし、まだできたてホヤホヤなのでバグがあるようですが、今後に期待したいと思います。
本エントリがみなさんの参考になれば幸いです。